iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 12

Kotlin 手刻 Ktor 從零開始 Day 12 Pipeline 的概念,請求處理的洋蔥模型

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

到第 11 篇,我們已經能把 request 導到正確的 handler 了。但真實世界的 API 不會只有 handler。你需要記 log、統一處理錯誤、加 CORS header、做 authentication。如果每個 handler 都自己寫一遍,框架就失去意義。如果把所有邏輯塞進 Application,又會變成一團

所以我們需要 Pipeline (或叫 Middleware chain),把橫切關注點 (cross-cutting concerns) 用「可組合」的方式串起來

這篇先把概念和型別講清楚。下一篇 (第 13 篇) 再實作可執行的 Pipeline

洋蔥模型,Middleware 的心智模型

把一次 request 想成進出一棟大樓。進門時保全檢查 (Auth),走廊上有人記錄來訪資訊 (Logging),進房間後真正處理事情 (Handler),出來時把結果包裝成一致的格式 (Error handling)

Middleware 可以在 handler 前做事,也可以在 handler 後做事。所以 pipeline 常被畫成洋蔥模型

Request
  │
  ▼
┌──────────────────────────────────┐
│ Logging (before): 記錄開始時間   │
│  ┌────────────────────────────┐  │
│  │ Auth (before): 驗證 token  │  │
│  │  ┌──────────────────────┐  │  │
│  │  │     Handler          │  │  │
│  │  │  ok("Hello!")        │  │  │
│  │  └──────────────────────┘  │  │
│  │ Auth (after): (不做事)     │  │
│  └────────────────────────────┘  │
│ Logging (after): 計算耗時、印 log│
└──────────────────────────────────┘
  │
  ▼
Response

每一層 middleware 包住內層。request 從外往內穿過每層的 before 邏輯,到達 handler,然後 response 從內往外穿過每層的 after 邏輯。像洋蔥一樣一層一層

擴充 RelixCall,加入 CallContext

Middleware 之間、middleware 與 handler 之間,常需要共享資料。trace id、開始時間、登入使用者 (principal)、feature flags,這些都不應該塞進 RelixRequest (那是 HTTP 原始資訊),也不適合用全域變數

先寫測試確認 CallContext 的行為

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull

class CallContextTest {

    @Test
    fun `set and get value`() {
        val context = CallContext()
        context.set("traceId", "abc-123")

        assertEquals("abc-123", context.get<String>("traceId"))
    }

    @Test
    fun `getOrNull returns null for missing key`() {
        val context = CallContext()

        assertNull(context.getOrNull<String>("missing"))
    }

    @Test
    fun `get throws when key is missing`() {
        val context = CallContext()

        assertFailsWith<IllegalStateException> {
            context.get<String>("missing")
        }
    }

    @Test
    fun `set overwrites existing value`() {
        val context = CallContext()
        context.set("count", 1)
        context.set("count", 2)

        assertEquals(2, context.get<Int>("count"))
    }

    @Test
    fun `typed key set and get`() {
        val context = CallContext()
        val traceIdKey = CallContext.Key<String>("traceId")

        context[traceIdKey] = "abc-123"

        assertEquals("abc-123", context[traceIdKey])
    }

    @Test
    fun `RelixCall has context`() {
        val request = RelixRequest("GET", "/", emptyMap(), emptyMap(), ByteArray(0))
        val app = RelixApplication()
        val call = RelixCall(app, request)

        call.context.set("userId", 42)
        assertEquals(42, call.context.get<Int>("userId"))
    }
}

getgetOrNull 的兩個測試都是在測「key 不存在」的行為,一個丟 IllegalStateException、一個回 null,typed key 那個用的是 operator get / operator set,所以呼叫端寫起來像在操作 map

實作本身不長

class CallContext {
    data class Key<T : Any>(val name: String)

    private val attributes = mutableMapOf<String, Any>()

    operator fun <T : Any> set(key: Key<T>, value: T) {
        attributes[key.name] = value
    }

    @Suppress("UNCHECKED_CAST")
    operator fun <T : Any> get(key: Key<T>): T? = attributes[key.name] as? T

    fun <T : Any> set(key: String, value: T) {
        attributes[key] = value
    }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> get(key: String): T =
        attributes[key] as? T
            ?: throw IllegalStateException("No attribute found for key: $key")

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> getOrNull(key: String): T? =
        attributes[key] as? T
}

然後更新 RelixCall

class RelixCall(
    val application: RelixApplication,
    val request: RelixRequest,
    internal var pathParams: Map<String, String> = emptyMap(),
    internal var matchedRoute: Route? = null,
) {
    val context = CallContext()

    fun pathParam(name: String): String =
        pathParams[name] ?: error("Missing path param: $name")

    fun pathParamOrNull(name: String): String? = pathParams[name]
}

除了 context,這裡還多了一個 matchedRoute,第 13 篇的 pipeline 會在匹配階段把它寫進來,第 23 篇的 auth middleware 要靠它知道目前打到哪條 route

字串 API 方便示範基本概念,typed Key<T> 則把 key 名稱和期望的型別放在同一個物件裡,呼叫端不用每次重打字串,也不用重複寫型別參數。兩者先並存,第 23 篇的 Principal 會使用 typed key

object Keys {
    const val TRACE_ID = "traceId"
    const val START_TIME = "startTime"
}

那三個 @Suppress("UNCHECKED_CAST") 做了什麼

回頭看 CallContext 的實作,三個 as? T 都會讓 compiler 發出 unchecked cast 警告,意思是「這個 cast 我沒辦法在 runtime 幫你驗」,原因在 T 只是普通的型別參數,沒有 reified,JVM 的泛型會被擦除 (type erasure),編譯完成之後 CallContext 裡面根本不知道 T 是誰,T 的上界又是 Any,compiler 連可以 cast 的目標都沒有,as? T 實際編譯出來只剩一個 null 檢查,跟型別無關

所以「型別填錯就會回 null」和「型別填錯就會丟例外」這兩個直覺都是錯的

val c = CallContext()
c.set("count", 1)                            // 存進去的是 Int

val a: Any? = c.getOrNull<String>("count")
println(a)                                   // 1,型別參數寫 String 也照樣把 Int 給你

val b: Any = c.get<String>("count")
println(b)                                   // 1,沒有丟 IllegalStateException

get 特別容易誤會。?: throw IllegalStateException(...) 看起來像是在處理「cast 失敗」,但 as? T 從來不會失敗,只有 map 裡真的沒這個 key (attributes[key] 回 null) 的時候才會走到 throw

c.get<String>("missing")   // IllegalStateException: No attribute found for key: missing

換句話說,getOrNull 唯一保證的是「key 不存在時回 null」,get 唯一保證的是「key 不存在時丟 IllegalStateException」,兩個都不保證型別

那型別錯誤跑去哪了 ? 跑到呼叫端

val s: String? = c.getOrNull<String>("count")
// ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

同一個呼叫,上面用 Any? 接沒事,這裡改用 String? 接就炸掉,差別在 compiler 會依照呼叫端宣告的型別插一道 CHECKCASTCallContext 內部放過去的東西到這裡才被攔下來,麻煩的是 stack trace 最上面那個 frame 指的是讀的那一行,不是當初把 Int 寫進去的那一行,在 pipeline 裡這兩行還可能隔了好幾層 middleware

想要真的 runtime 檢查,型別參數得帶 reified

inline fun <reified T : Any> getOrNull(key: String): T? = attributes[key] as? T

reified 會讓 compiler 把 T 的實際型別 inline 到每個呼叫點,as? T 就變成貨真價實的 instanceof,型別不符回 null,@Suppress 也可以拿掉,代價是 public 的 inline 函式看不到 private 成員,attributes 得改標成 @PublishedApi internal,一個純內部的欄位變成技術上對外可見,operator get 也要跟著 inline,typed key 那套 API 一起被拉進來

這裡選擇不換,就直接使用 @Suppress,Relix 的 context 只有框架自己和範例在用,寫入和讀取的程式碼都在手邊,為了這點保護把內部欄位開出去不太划算

但這不是一體適用的答案,第 23 篇的 principal<T>() 就選了另一個,那裡要在型別不符時回 500 而不是把錯的 principal 交給 handler,簽名就寫成 inline fun <reified T : Principal>,那個 500 完全靠 reified

編譯期看得到、runtime 就不見了的型別,等一下講 typealias 的時候還會再遇到一次

型別設計,Next 與 RelixMiddleware

這是系列中最考驗 Kotlin 型別理解力的地方。我們一步一步來

這一節的兩個 typealias 開一個新檔案 Pipeline.kt 放它們,第 13 篇的 buildPipeline 也會加進同一個檔案

先定義「下一步」

typealias Next = RelixCall.() -> RelixResponse

這就是第 6 篇定義 RelixHandler 時預告過的那個型別

NextRelixHandler 的型別一模一樣,一個在 RelixCall 上面執行的函式,回傳 RelixResponse。為什麼不直接用 RelixHandler ? 因為語意不同,RelixHandler 是「使用者寫的最終處理邏輯」,Next 是「pipeline 裡的下一層」。型別一樣,名字不同,讓讀程式碼的人知道自己在哪

要注意 Kotlin 的 typealias 是透明的編譯期別名,不是 nominal type,也不是 newtype,compiler 解析後,bytecode 看不到 NextRelixHandler,只剩底層的 Function1<RelixCall, RelixResponse>,因此你可以把 RelixHandler 直接傳給期待 Next 的位置

這裡分成兩個名稱,是為了在 IDE 與函式簽名中表達不同角色,若要讓 compiler 把它們視為不同型別,就得用 wrapper 或 value class

同一個檔案裡再定義 middleware

typealias RelixMiddleware = RelixCall.(next: Next) -> RelixResponse

這行需要慢慢拆。從外到內

  1. RelixCall.( ... ),這是 receiver lambda,所以 middleware 裡面 thisRelixCall
  2. next: Next,middleware 多拿到一個參數 next,代表「把控制權交給下一層」
  3. -> RelixResponse,middleware 必須回傳 response
  4. next 本身也是 RelixCall.() -> RelixResponse,所以呼叫 next() 時,this 還是同一個 RelixCall
  5. middleware 可以選擇不呼叫 next(),直接回 response,這就是「短路」

把這五點綜合起來,middleware 是一個「能看到 request、能決定要不要往下傳、能修改 response」的函式

三個具體的 Middleware 範例

概念講再多不如直接看程式碼,以下是三個典型的 middleware

這三個是拿來示範概念的,不是框架的正式元件,不用在 src/ 開檔案,跟測試放在一起就好,可以寫在 MiddlewareTest.kt 的 class 外面,這篇後面的測試會直接呼叫它們

正式的 logging、error handling、CORS middleware 分別在第 15、16、17 篇才做

範例一,Request ID 注入

val requestIdMiddleware: RelixMiddleware = { next ->
    val traceId = java.util.UUID.randomUUID().toString().take(8)
    context.set("traceId", traceId)

    val response = next()
    response.header("X-Request-Id", traceId)
}

before: 產生 trace id,放進 context,after: 把 trace id 加到 response header,handler 不用知道 trace id 怎麼來的,需要的話從 context.get("traceId") 拿就好

範例二,計時

val timingMiddleware: RelixMiddleware = { next ->
    val start = System.currentTimeMillis()
    val response = next()
    val elapsed = System.currentTimeMillis() - start

    response.header("X-Response-Time", "${elapsed}ms")
}

before: 記開始時間。after: 算差值,加到 header。next() 那一行的下面就是 after 邏輯,因為 next() 回來代表 handler (和所有內層 middleware) 都跑完了

範例三,Auth 短路

val authMiddleware: RelixMiddleware = { next ->
    val token = request.headers["Authorization"]?.firstOrNull()
    if (token == null) {
        RelixResponse(
            statusCode = 401,
            headers = mapOf("WWW-Authenticate" to listOf("Bearer")),
            body = "Unauthorized".toByteArray(),
        )
    } else {
        context.set("token", token)
        next()
    }
}

如果沒有 token,直接回 401,不呼叫 next()。後面的 middleware 和 handler 都不會執行,如果有 token 就放進 context 繼續往下,這就是短路

三個範例展示了 middleware 的三種典型用途,注入資料、量測行為、條件短路

這三個 middleware 現在就能測

pipeline 要到下一篇才做出來,所以直覺上會覺得這三個東西還沒辦法驗證,要等 pipeline 把它們串起來才行

但其實 middleware 就只是一個函式,next 也只是一個函式,測試想扮演「下一層」的時候,自己傳一個 lambda 進去就好

RelixMiddleware 的型別是 RelixCall.(Next) -> RelixResponse,Kotlin 對這種帶 receiver 的函式型別,可以直接用 middleware(call) { ... } 的形式呼叫,前面的 call 是 receiver,後面的 lambda 就是 next

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue

// 上面那三個 middleware 的 val 宣告就放在這裡,class 外面

class MiddlewareTest {

    private fun callWith(headers: Map<String, List<String>> = emptyMap()) =
        RelixCall(
            RelixApplication(),
            RelixRequest("GET", "/", headers, emptyMap(), ByteArray(0)),
        )

    @Test
    fun `requestId puts traceId in context and echoes it in header`() {
        val call = callWith()
        var seenInsideHandler: String? = null

        val response = requestIdMiddleware(call) {
            seenInsideHandler = context.get<String>("traceId")
            ok("done")
        }

        assertEquals(8, seenInsideHandler!!.length)
        assertEquals(listOf(seenInsideHandler), response.headers["X-Request-Id"])
    }

    @Test
    fun `timing adds a response time header`() {
        val call = callWith()

        val response = timingMiddleware(call) { ok("done") }

        val value = response.headers["X-Response-Time"]!!.single()
        assertTrue(value.endsWith("ms"))
        assertTrue(value.removeSuffix("ms").toLong() >= 0)
    }

    @Test
    fun `auth without token returns 401 and never calls next`() {
        val call = callWith()
        var handlerRan = false

        val response = authMiddleware(call) {
            handlerRan = true
            ok("secret")
        }

        assertEquals(401, response.statusCode)
        assertEquals(listOf("Bearer"), response.headers["WWW-Authenticate"])
        assertEquals(false, handlerRan)
        assertNull(call.context.getOrNull<String>("token"))
    }

    @Test
    fun `auth with token puts it in context and calls next`() {
        val call = callWith(mapOf("Authorization" to listOf("Bearer abc-123")))
        var handlerRan = false

        val response = authMiddleware(call) {
            handlerRan = true
            ok("secret")
        }

        assertEquals(200, response.statusCode)
        assertEquals(true, handlerRan)
        assertEquals("Bearer abc-123", call.context.get<String>("token"))
    }
}

第一個測試同時驗了 before 和 after 兩段,seenInsideHandler 是在 next 的 lambda 裡讀的,讀得到就代表 trace id 在呼叫下一層之前就進了 context,而 response header 的值跟它一樣,代表 after 段拿到的是同一個 id

第三個的 handlerRan 是短路測試的重點。斷言 401 只能證明「回傳的是 401」,證明不了「下一層真的沒被執行」,這兩件事在 middleware 裡是分開的,一個實作完全可能先跑完 next() 再把結果丟掉改回 401,狀態碼一樣,但該擋的副作用早就發生了。用一個旗標記錄 lambda 有沒有被呼叫,才是真的在測短路

這件事本身也是這個型別設計的一個好處,middleware 不依賴 pipeline、不依賴 server、不依賴 router,它的所有外部互動就只有 callnext 兩個參數,兩個都能在測試裡直接給

對比其他框架

不同框架的 API 長相不一樣,但概念一致

框架 Middleware 寫法 特色
Express (req, res, next) => { next() } 三個參數,next() 是 callback
Ktor onCall { ... } / 進階 intercept(phase) { proceed() } 一般自訂 plugin 用 hook API,內部仍是 phase-based pipeline
Spring Filter.doFilter(req, res, chain) Servlet filter chain,加上 interceptor 和 AOP
Relix RelixCall.(next) -> RelixResponse receiver lambda,handler 和 middleware 的 this 都是 RelixCall

Relix 的設計刻意不做 phase 或 chain 物件,用一個 receiver lambda 搞定,好處是學習曲線平,壞處是當 middleware 數量多到一定程度時,執行順序全靠「安裝順序」來控制,沒有 Ktor 那種 plugin hook / phase 可以明確標記「我要在某個處理階段介入」

整體流程圖

把路由和 middleware 放在一起看

RelixCall
  │
  ▼
Pipeline
  │  Middleware A (before)
  │  Middleware B (before)
  │    │
  │    ▼
  │  Router.match(method, path)
  │    │
  │    ├─ Matched → handler(call) → RelixResponse
  │    ├─ NotFound → 404
  │    └─ MethodNotAllowed → 405
  │    │
  │  Middleware B (after)
  │  Middleware A (after)
  │
  ▼
RelixResponse

Router 是 pipeline 的最後一層,middleware 包在外面,request 先經過所有 middleware 的 before 邏輯,然後到 router 找 handler 執行,拿到 response 後再反向經過所有 middleware 的 after 邏輯

常見陷阱與設計取捨

RelixMiddleware 的 receiver 是 RelixCall,跟 RelixHandler 一樣 ?

對,middleware 和 handler 的 this 都是 RelixCall,差別在 middleware 多了一個 next 參數,這代表 middleware 裡可以直接存取 requestcontextpathParam(),不用透過額外的參數,寫起來像在同一個 scope 裡面

這不就是 Decorator 嗎 ?

結構上是,而且是函式版的,middleware 包住 handler、包完還是同一個型別、可以一層一層疊上去,這些都是 Decorator 的特徵,第 13 篇的 buildPipeline 從最內層往外包的過程就是在 decorate

有兩個地方不一樣。第一,Decorator 的契約是「增強」,幾乎一定會呼叫被它包住的那個物件,但 middleware 可以完全不呼叫 next(),像 authMiddleware 這種按權限決定放不放行的,意圖就比較接近 GoF (Gang of Four) 分類裡的 Protection Proxy (保護代理),負責決定要不要往下轉發

第二,middleware 不持有下一層,Decorator 在建構時就把被包住的物件存進欄位,兩者從此綁在一起,而 RelixCall.(next: Next) -> RelixResponse 裡的 next 要等到呼叫時才傳進來,設定倒是可以帶在自己身上 (第 15 篇的 loggingMiddleware(logger) 就是),但下一層永遠是參數,所以同一個 middleware 可以組進任何一條 pipeline

順帶一提,middleware 也常被說成 Chain of Responsibility (責任鏈),兩個類比各抓到一半,CoR 抓的是「可以中途停住」,Decorator 抓的是「包起來、還有 after 那半」,沒有哪一個 GoF 模式剛好涵蓋全部

CallContext 為什麼同時保留 String key 和 typed key ?

String key 適合簡短範例,Key<T> 則把名稱和期望的型別寫在同一個物件裡,跨 plugin 共用的資料優先用 typed key,"traceId" 這串字只會出現在 key 的定義處,呼叫端也不必每次都寫型別參數

val TraceIdKey = CallContext.Key<String>("traceId")

context[TraceIdKey] = "req-123"
val traceId: String? = context[TraceIdKey]

不過 Key<T> 綁的只有呼叫端的型別推導,backing map 認的還是 key.name 那個字串

val c = CallContext()
c[CallContext.Key<Int>("dup")] = 99

val v: Any? = c[CallContext.Key<String>("dup")]
println(v)                                                       // 99,name 一樣就是同一格

println(CallContext.Key<Int>("x") == CallContext.Key<String>("x"))  // true

最後一行是 Key 宣告成 data class 的直接後果,equals 只比建構子參數,這裡只有 name 一個,T 不是參數,runtime 也沒有它,Key<Int>("x")Key<String>("x") 於是成了相等的兩個物件

typed key 和 String key 也共用同一個 attributescontext["traceId"]context[CallContext.Key<String>("traceId")] 指向同一格資料,兩套 API 因此可以互通,代價是字串那邊寫錯型別,typed key 這邊讀出來一樣會出事

所以 typed key 真正解決的是拼錯字和呼叫端每次都要寫型別參數,這兩個問題在 plugin 一多的時候很常發生,它管不到的是型別對不對,那要靠 reified,第 23 篇的 PrincipalKey 沿用同一套 API,不再另外定義 AttributeKey,型別檢查則交給 principal<T>()reified 簽名

為什麼這篇不實作 Pipeline ?

因為概念和實作分開講,這篇先把 middleware 的型別、使用方式、洋蔥模型都講完了,下一篇 (第 13 篇) 就用 fold 把 middleware 串成可執行的 pipeline,如果兩者混在一起,型別設計的動機會被實作細節淹沒


小結

Pipeline 的概念是洋蔥模型,middleware 包在 handler 外面,request 進去、response 出來,每層都能做 before/after 處理或短路,RelixMiddleware 的型別是 RelixCall.(next: Next) -> RelixResponse,比 handler 多一個 next 參數,CallContext 讓 middleware 之間可以共享資料,型別都準備好了,第 13 篇就來把它們串成真正可執行的 pipeline


下一篇

下一篇實作 Pipeline,用 fold 把 middleware list 串成一個可執行的函式,並用測試驗證執行順序、before/after 行為和短路


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 11 路由 DSL (下),路由群組與巢狀路由
下一篇
Kotlin 手刻 Ktor 從零開始 Day 13 實作同步版 Pipeline,把 Middleware 串成鏈
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言